Skip to content

Conversation

@Parsium
Copy link
Contributor

@Parsium Parsium commented Dec 23, 2025

Proposed behaviour

For Carbon components involving React Portals, resolve inconsistencies in rendering behaviour:

  • Portal - when unmounting, properly clean up DOM node created as the container for the portal content
  • Popover - always render portal content in document body or nearest Carbon modal, to avoid the need to create and cleanup a DOM node.
  • CarbonProvider - ensure global used in updating topmost modal is cleaned up properly.

Current behaviour

Since #7148 was released, several Carbon components that use React Portals show unexpected behaviour when React Strict Mode is enabled, such as modals and popups not appearing at all. This occurs because these components have rendering inconsistencies that are surfaced by the stricter Strict Mode checks introduced in React v18.

Before upgrading Carbon to support newer React versions, we should ensure all components behave correctly under Strict Mode to prepare for future React changes.

Checklist

  • Commits follow our style guide
  • Related issues linked in commit messages if required
  • Screenshots are included in the PR if useful
  • All themes are supported if required
  • Unit tests added or updated if required
  • Playwright automation tests added or updated if required
  • Storybook added or updated if required
  • Translations added or updated (including creating or amending translation keys table in storybook) if required
  • Typescript d.ts file added or updated if required
  • Related docs have been updated if required

QA

  • Tested in provided StackBlitz sandbox/Storybook
  • Add new Playwright test coverage if required
  • Carbon implementation matches Design System/designs
  • UI Tests GitHub check reviewed if required

Additional context

Testing instructions

  1. Run Storybook with React Strict Mode enabled: npm run start:strict-mode
  2. Check the following components and compare behaviour with their production docs counterparts:
  • AdvancedColorPicker
  • Alert
  • Confirm
  • Dialog
  • Sidebar
  • MenuFullscreen
  • ResponsiveVerticalMenu
  • Toast
  • Tooltip
  • VerticalMenuFullscreen

export const TopModalProvider = ({ children }: { children: ReactNode }) => {
const [topModal, setTopModal] = useState<HTMLElement | null>(null);

// can't add the setter to the global list inside useEffect because that doesn't run until
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question(non-blocking): Are we all good to remove this ref and condition for first render? The comment states that we can't add the setter to the global list inside a useEffect and we're now doing that.

Copy link
Contributor Author

@Parsium Parsium Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be okay now, since Modal no longer attempts to access this global on initial render.

Portal now creates its attachment node on a separate render to when the portal content is displayed. Because of this change, Modal has been re-structured (see below) so its internal hooks, like useModalManager, are only called once the modal content is rendered.

// src/__internal__/modal/modal.component.tsx#L179

const Modal = (props: ModalProps) => (
  <Portal>
   {/* 👇 `Portal` only renders this once its attachment node is present */}
    <ModalRoot {...props} />
  </Portal>
);

const [portalNode, setPortalNode] = useState<HTMLElement | null>(null);
const [didCreateNode, setDidCreateNode] = useState(false);

useEffect(() => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking):

useEffect(() => {
  // Find or create portal node
  let node = document.getElementById(id);
  const shouldCreateNode = !node;

  if (shouldCreateNode) {
    node = document.createElement("div");
    node.setAttribute("id", id);
    document.body.appendChild(node);
  }

  setDidCreateNode(shouldCreateNode);
  setPortalNode(node);

  // Cleanup portal node if component created it
  return () => {
    if (shouldCreateNode) {
      node?.remove();
    }
  };
}, [id]);

@@ -0,0 +1,22 @@
import { useEffect, useState } from "react";

function canUseDOM() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking): we could use the getDocument and getWindow utils here instead

nineteen88
nineteen88 previously approved these changes Jan 15, 2026
PortalContext was added to ensure Portal was captured by Chromatic properly, by
attaching the React portal to Storybook's root element.

Storybook no longer uses the referenced root, so this context is unused.
edleeks87
edleeks87 previously approved these changes Jan 16, 2026
nineteen88
nineteen88 previously approved these changes Jan 16, 2026
@tomdavies73 tomdavies73 self-assigned this Jan 16, 2026
@tomdavies73 tomdavies73 force-pushed the FE-7197/strict-mode-fixes-v2 branch from 6e7cd4b to 0b8e605 Compare January 16, 2026 16:03
@dariusbercea-sage dariusbercea-sage marked this pull request as ready for review January 19, 2026 11:04
@dariusbercea-sage dariusbercea-sage requested review from a team as code owners January 19, 2026 11:04
@tomdavies73 tomdavies73 dismissed stale reviews from edleeks87 and nineteen88 via 62e315e January 20, 2026 16:02
@tomdavies73 tomdavies73 force-pushed the FE-7197/strict-mode-fixes-v2 branch from 62e315e to 6f9cbda Compare January 20, 2026 16:32
@tomdavies73 tomdavies73 force-pushed the FE-7197/strict-mode-fixes-v2 branch from 6f9cbda to 688ea30 Compare January 20, 2026 16:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Development

Successfully merging this pull request may close these issues.

5 participants